# 实战篇 4:天气页面数据获取和交互实现

天气预报页面主要有两个重要的流程:获取地理位置和获取天气数据。本节重点介绍这两部分的代码实现。

# 定位和逆地址查询

获取地理位置功能可以拆成两个步骤:

  1. 使用微信 wx.getLocation 方法获取用户当前位置的经纬度
  2. 使用拿到的经纬度请求腾讯地图的逆地址解析接口,获取省市县和详细地址

下面来逐一介绍。

# 获取用户当前位置经纬度

小程序可以使用 wx.getLocation 方法获取用户的当前位置经纬度。wx.getLocation 默认获取的是 wgs84 坐标系,即 GPS 的坐标系,而国内地图(除百度地图外)一般用的都是 GCJ02(国测局坐标,又称为“火星坐标系”)的坐标系,所以需要传入 type 来指定坐标系统。

wx.getLocation({
  type: 'gcj02',
  success: this.updateLocation,
  fail: (e) => {
    // console.log(e)
    this.openLocation()
  }
})

Tips: 出于国家安全考虑,国内使用的坐标系都是经过国测局统一加密偏移后的坐标,所以我们拿到的坐标并不是真正的坐标,国测局统一加密后的坐标系就是 GCJ02。国测局规定:国内地图产品需要用 GCJ02 的坐标系,或者在 GCJ02 基础上再次加密的坐标系,百度自有的 BD09 坐标系就是在 GCJ02 基础上再次加密的坐标系。

GPS 定位拿到的是 WGS84 坐标系,直接在国内地图上使用或者调用国内地图的位置 API 服务都会计算有偏移。因为我们实战中用的逆地址解码服务是腾讯地图服务,腾讯地图的坐标系是 GCJ02,所以 wx.getLocation 的参数 type 应该是 gcj02,而不是默认的 wgs84。如果调用 wx.getLocation 不传入 type 则获取的 wgs84 坐标会有偏移。同理,使用 wx.openLocation 也要注意坐标系问题。

# 根据经纬度获取当前地址

wx.getLocation 方法返回有当前用户的经纬度,具体返回的数据有:

参数 说明
latitude 纬度,浮点数,范围为 -90~90,负数表示南纬
longitude 经度,浮点数,范围为 -180~180,负数表示西经
speed 速度,浮点数,单位 m/s
accuracy 位置的精确度
altitude 高度,单位 m
verticalAccuracy 垂直精度,单位 m(Android 无法获取,返回 0)
horizontalAccuracy 水平精度,单位 m

经纬度信息要转化成具体的地理位置,需要使用地图的逆地址查询接口,笔者在这里使用了腾讯地图的 API (opens new window)。

首先在腾讯地图开放平台注册账号,注册后登录,在「我的控制台 -> 密钥(key)管理」中可以添加密钥。密钥是我们使用 API 必须传递的一个参数,每个不同的应用对应不同的密钥。

在小程序内,腾讯地图 API 的调用方式主要有两种:

  • 通过腾讯地图提供的小程序 SDK
  • WebService API 方式,即直接调用 API 的接口

这里笔者选择了 WebService API,原因如下:

  1. 小程序 SDK 和 WebService API 都很方便,不需要加密数据,密钥都是对外暴露的
  2. WebService API 使用微信 wx.request 方法可以直接发送请求,对代码无侵入性
  3. 小程序 SDK 本质上还是用 wx.request 封装的 WebService API

根据逆地址查询接口的文档 (opens new window),只需要传入密钥和 location (经纬度组合)信息即可:

// lib/api.js
const QQ_MAP_KEY = 'ZVXBZ-xxxxxx-xxxxx-xxxxxK-LQFU6'
/**
 *  逆地址查询
 * @param {*} lat
 * @param {*} lon
 */
export const geocoder = (lat, lon, success = () => {}, fail = () => {}) => {
  return wx.request({
    url: 'https://apis.map.qq.com/ws/geocoder/v1/',
    data: {
      location: `${lat},${lon}`,
      key: QQ_MAP_KEY,
      get_poi: 0
    },
    success,
    fail
  })
}

# 完整获取地址的流程

完成了上面的获取经纬度和逆地址查询,就可以在 index.js 中进行整个地址获取的流程了,代码从 onLoad 调用 this.getLocation 开始,全部代码如下:

// weather/index.js
onLoad(){
  this.getLocation()
},
// 处理逆地址
getAddress(lat, lon, name) {
  wx.showLoading({
    title: '定位中',
    mask: true
  })
  let fail = (e) => {
    // console.log(e)
    this.setData({
      address: name || '北京市海淀区西二旗北路'
    })
    wx.hideLoading()

    this.getWeatherData()
  }
  geocoder(
    lat,
    lon,
    (res) => {
      wx.hideLoading()
      let result = (res.data || {}).result
      // console.log(1, res, result)

      if (res.statusCode === 200 && result && result.address) {
        let {address, formatted_addresses, address_component} = result
        if (formatted_addresses && (formatted_addresses.recommend || formatted_addresses.rough)) {
          address = formatted_addresses.recommend || formatted_addresses.rough
        }
        let {province, city, district: county} = address_component
        this.setData({
          province,
          county,
          city,
          address: name || address
        })
        this.getWeatherData()
      } else {
        //失败
        fail()
      }
    },
    fail
  )
},
// 更新 data 数据,调用 getAddress
updateLocation(res) {
  let {latitude: lat, longitude: lon, name} = res
  let data = {
    lat,
    lon
  }
  if (name) {
    data.address = name
  }
  this.setData(data)
  this.getAddress(lat, lon, name)
},
getLocation() {
  // 获取经纬度
  wx.getLocation({
    type: 'gcj02',
    success: this.updateLocation,
    fail: (e) => {
      // console.log(e)
      this.openLocation()
    }
  })
},
// 检测到失败,则提示用户打开位置权限
openLocation() {
  wx.showToast({
    title: '检测到您未授权使用位置权限,请先开启哦',
    icon: 'none',
    duration: 3000
  })
},

# 点击地址栏事件处理

在天气预报的顶部地址栏部分绑定了 tap 事件,点击地址栏则会打开地图让用户重新选择位置,这可以通过小程序提供的 wx.chooseLocation 方法来实现。

<view class="location" bindtap="chooseLocation">
  <icon type="dingwei" />
  <text>{{ address }}</text>
</view>

事件 chooseLocation 的代码如下:

chooseLocation() {
  wx.chooseLocation({
    success: (res) => {
      let {latitude, longitude} = res
      let {lat, lon} = this.data
      if (latitude == lat && lon == longitude) {
        this.getWeatherData()
      } else {
        this.updateLocation(res)
      }
    }
  })
}

# 和风天气数据获取

和风天气接口是提供三种调用方式的,不过对于 Web 端产品来说,只有普通 KEY 请求和签名请求两种方式,具体可以查看天气 API 接口说明 (opens new window)。

基于以下两点考虑,本小册中的天气服务采用了签名的认证方式:

  1. 为了提高安全性
  2. 练习云函数的使用方法

具体签名的算法可以参考加密签名认证 (opens new window),这个不是我们介绍的重点,笔者先把签名算法代码贴上:

const crypto = require('crypto')
const KEY = 'e8dd4902xxxxxxxxxxxxxxcb4df'
const USER_ID = 'HE11212121212121299'
function generateSignature(params) {
  params.username = USER_ID
  let data =
    Object.keys(params)
      .filter((key) => {
        return params[key] !== '' && key !== 'sign' && key !== 'key'
      })
      .sort()
      .map((key) => {
        return `${key}=${params[key]}`
      })
      .join('&') + KEY
  return crypto.createHash('md5').update(data).digest('base64')
}

有了认证的算法,就可以在云函数中发送数据请求。以获取天气集合接口 (opens new window)为例,介绍下云函数的使用方法,获取空气质量相对简单且类似,具体代码中有详细注释:

// server/cloud-functions/he-weather
// 请求的地址
const API_URL = 'https://free-api.heweather.com/s6/weather'
// request 模块
const request = require('request')

// 引入云函数功能工具方法,跟空气质量公用
// gulp prod 打包的时候将公共 utils 库嵌入式引入
/*<jdists import="../../inline/utils.js" />*/

// 普通 mock server 的代码直接将 utils 库当模块引入
/*<remove>*/
const $ = require('../../inline/utils')
/*</remove>*/

// 按照云函数的规定,必须导出 main 函数
exports.main = async (event) => {
  const {lat,lon} = event
  let location = `${lat},${lon}`
  let params = {
    location,
    t: Math.floor(Date.now() / 1e3),
    unit: 'm'
  }
  // 生成签名
  params.sign = $.generateSignature(params)
  let query = []
  for (let i in params) {
    query.push(`${i}=${encodeURIComponent(params[i])}`)
  }
  let url = API_URL + '?' + query.join('&')
  // 将 request.get 方法改造成 promise 方式
  return new Promise((resolve, reject) => {
    request.get(url, (error, response, body) => {
      if (error || response.statusCode !== 200) {
        reject(error)
      } else {
        try {
          // 统一处理接口返回的数据
          let rs = $.handlerData(JSON.parse(body))
          resolve(rs)
        } catch (e) {
          reject(e)
        }
      }
    })
  })
}

完成获取天气集合数据,天气预报页面还用到了空气质量相关的接口,这个免费接口使用的参数不是经纬度而是城市名称,具体获取数据的代码跟天气数据接口类似,这里直接贴出代码:

// server/cloud-functions/he-air
// const path = require('path')
const API_URL = 'https://free-api.heweather.com/s6/air/now'
const request = require('request')
/*<jdists import="../../inline/utils.js" />*/

/*<remove>*/
const $ = require('../../inline/utils')
/*</remove>*/

exports.main = async (event) => {
  let location = event.city
  let params = {
    location,
    t: Math.floor(Date.now() / 1e3),
    unit: 'm'
  }
  // 生成签名
  params.sign = $.generateSignature(params)
  let query = []
  for (let i in params) {
    query.push(`${i}=${encodeURIComponent(params[i])}`)
  }
  let url = API_URL + '?' + query.join('&')
  // console.log(url)
  return new Promise((resolve, reject) => {
    // console.log(url)
    request.get(url, (error, response, body) => {
      if (error || response.statusCode !== 200) {
        reject(error)
      } else {
        try {
          let data = JSON.parse(body)
          // console.log(data)
          if (data && data.HeWeather6 && data.HeWeather6[0].air_now_city) {
            let {aqi, qlty} = data.HeWeather6[0].air_now_city
            resolve({
              status: 0,
              aqi,
              color: $.airBackgroundColor(aqi),
              name: qlty
            })
          } else {
            resolve({
              status: 500
            })
          }
          // resolve(rs)
        } catch (e) {
          reject(e)
        }
      }
    })
  })
}

# 天气预报页面流程图

# 下拉刷新

天气预报页面是支持下拉刷新的,在页面配置中 index.json 已经将 enablePullDownRefresh 设置为 true,我们还需要在 index.js 中增加下拉事件监听,监听下拉刷新操作,然后重新获取定位和天气数据,最后更新页面:

// weather/index.js
onPullDownRefresh() {
  this.getWeatherData(() => {
    wx.stopPullDownRefresh()
  })
}

# 配置分享文案

为了提升我们小程序的分享体验,笔者设计了分享文案,通过监听 onShareAppMessage 事件,用户分享小程序的时候增加当前定位和天气相关的文案,onShareAppMessage 只需要返回一个分享文案和路径的对象即可:

// weather/index.js
onShareAppMessage() {
  // 如果获取数据失败,则没有位置和天气信息,那么需要个默认文案
  if (!isUpdate) {
    return {
      title: '我发现一个好玩的天气小程序,分享给你看看!',
      path: '/pages/weather/index'
    }
  } else {
    // 如果有天气信息,那么需要给 path 加上天气信息
    const {lat, lon, address, province, city, county} = this.data
    let url = `/pages/weather/index?lat=${lat}&lon=${lon}&address=${address}&province=${province}&city=${city}&county=${county}`

    return {
      title: `「${address}」现在天气情况,快打开看看吧!`,
      path: url
    }
  }
},

最终的效果如上图所示,分享出去的小程序链接是带着当前天气的数据的,但是这时候如果有人打开了链接,那么还是根据自己的定位信息查看天气,不能看到分享人的位置和天气信息,所以还需要在 onLoad 中,获取页面的 URL 参数,然后使用分享的地理位置直接获取天气数据,对应的代码如下:

// weather/index.js
onLoad() {
  // ......
  const pages = getCurrentPages() //获取加载的页面
  const currentPage = pages[pages.length - 1] //获取当前页面的对象
  const query = currentPage.options
  // 如果有地址,经纬度信息
  if (query && query.address && query.lat && query.lon) {
    let {province, city, county, address, lat, lon} = query
    // 取出这些数据,设置页面data
    // 利用setData的callback,保证数据设置完成后,获取天气信息
    this.setData(
      {
        city,
        province,
        county,
        address,
        lat,
        lon
      },
      () => {
        this.getWeatherData()
      }
    )
  } else {
    // 否则,正常逻辑:先获取地址,再获取天气数据
    this.getLocation()
  }
},

Tips: 在获取分享的 URL 数据之后通过 setData 设置地理位置信息,不应该直接在 setData 之后获取天气信息,而应该在 setData 的回调函数中调用 getWeatherData 获取天气数据,这是因为 setData 是异步的,可以参考基础篇 3:小程序架构及其实现机制 (opens new window)的相关介绍。

# 使用 Chart.js 绘制图表

笔者在七天天气模块使用了 Chart.js 来绘制一个温度走势图。Chart.js 是个 Canvas 版本的图表库,有人将其改造成了小程序版本 (opens new window)。由于本实战中的温度走势图相对比较简单,所以 Chart.js 可以胜任需求,先看下效果图。

首先在 <view class="week"/> 最后增加走势图图表的 canvas 组件:

<view class="week">
  <view class="week-weather">
    ....
  </view>
  <view class="week-chart">
    <canvas canvas-id="chart" id="chart"></canvas>
  </view>
</view>

通过绝对定位的方式,将 week-chart 放置在对应的位置,并且设置 canvas 的宽度和高度:

.week-chart {
  position: absolute;
  left: 0;
  right: 0;
  height: 272rpx;
  top: 262rpx;
}
.week-chart canvas {
  width: 750rpx;
  height: 272rpx;
}

绘制走势图使用 Chart.js 是使用了折线图(line)方式,而温度数值的标注,是给 Chart 注册了个 afterDatasetsDraw 钩子(hook)内,将数据遍历一遍之后,直接绘制将温度写上,具体代码如下:

// weather/index.js
import {fixChart, getChartConfig, drawEffect} from '../../lib/utils'
import Chart from '../../lib/chartjs/chart'
// Page 中定义的 drawChart 函数
drawChart() {
  const {width, scale, weeklyData} = this.data
  let height = CHART_CANVAS_HEIGHT * scale
  let ctx = wx.createCanvasContext('chart')
  fixChart(ctx, width, height)

  // 添加温度
  Chart.pluginService.register({
    afterDatasetsDraw(e, t) {
      ctx.setTextAlign('center')
      ctx.setTextBaseline('middle')
      ctx.setFontSize(16)

      e.data.datasets.forEach((t, a) => {
        let r = e.getDatasetMeta(a)
        r.hidden ||
          r.data.forEach((e, r) => {
            // 昨天的数据发灰
            ctx.setFillStyle(r === 0 ? '#e0e0e0' : '#ffffff')
            // 增加温度符号
            let i = t.data[r].toString() + '\xb0'
            let o = e.tooltipPosition()
            // 计算文字位置
            0 == a ? ctx.fillText(i, o.x + 2, o.y - 8 - 10) : 1 == a && ctx.fillText(i, o.x + 2, o.y + 8 + 10)
          })
      })
    }
  })

  return new Chart(ctx, getChartConfig(weeklyData))
}

# 小结

本节介绍了天气页面数据获取和交互的实现,重点讲解了地理位置获取和天气数据获取,地理位置使用 wx.request 请求腾讯地图 API,天气数据则使用云函数来获取数据。小节的最后介绍了下拉刷新和配置分享文案的实现。

阅读全文